You don't know js (scope&closures) 学习笔记

第一章:什么是作用域?

编译器理论

js是一种编译型语言,它 不是 像许多传统意义上的编译型语言那样预先被编译好,编译的结果也不能在各种不同的分布式系统间移植。

传统编译型语言处理,一块代码执行之前的处理分为三个步骤,成为“编译”:

  • 词法分析
  • 语法分析
  • 代码生成

和大多数其他语言的编译器一样,JavaScript 引擎要比这区区三步复杂太多了。

其一,javaScript 引擎没有(像其他语言的编译器那样)大把的时间去优化,因为 javaScript 的编译和其他语言不同,不是提前发生在一个构建的步骤中。对 javaScript 来说,在许多情况下,编译发生在代码被执行前的仅仅几微秒之内(或更少!)。为了确保最快的性能,JS 引擎将使用所有的招数(比如 JIT,它可以懒编译甚至是热编译,等等),而这远超出了我们关于“作用域”的讨论。

理解作用域

var a = 2;为例,用一个对话情形了解释作用域。

演员

1、引擎:负责从始至终的编译和执行javaScript程序。

2、编译器:引擎的朋友之一,负责解析和代码生成。

3、作用域:引擎的有一个朋友,收集并维护含有所有被声明标识符(变量)的一张表,并对当前执行中的代码如何访问这些代码制定严格的规则。

反复

引擎会看到两个语句:一个是编译器需要处理的语句,一个是引擎在执行期间处理的语句。

编译器对于var a = 2的处理过程:

1、遇到 var a编译器作用域 去查看对于这个特定的作用域集合,变量 a 是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为 a 的新变量。

2、然后 编译器引擎 生成稍后要执行的代码,来处理赋值 a = 2引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为 a 的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方。如果 引擎 最终找到一个变量,它就将值 2 赋予它。如果没有,引擎 将会举起它的手并喊出一个错误!

总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量(如果先前没有在当前作用域中声明过),第二,当执行时,引擎作用域 中查询这个变量并给它赋值,如果找到的话。

编译器术语

RHS(right hand side)查询 & LHS(left hand side)查询

换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。更简单的理解RHS:意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值“。例:

console.log( a );

这个指向 a 的引用是一个 RHS 引用,因为这里没有东西被赋值给 a。而是我们在查询 a 并取得它的值,这样这个值可以被传递进 console.log(..)

作为对比:

a = 2;

这里指向 a 的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到这个变量,将它作为 = 2 赋值操作的目标。

引擎 & 作用域对话

这是解释RHS和LHS更具体的一个栗子:

1
2
3
4
5
function foo(a) {
console.log( a ); // 2
}
foo( 2 );

让我们将上面的(处理这个代码段的)交互想象为一场对话。这场对话将会有点儿像这样进行:

引擎:嘿 作用域,我有一个 foo 的 RHS 引用。听说过它吗?

作用域;啊,是的,听说过。编译器 刚在一秒钟之前声明了它。它是一个函数。给你。

引擎:太棒了,谢谢!好的,我要执行 foo 了。

引擎:嘿,作用域,我得到了一个 a 的 LHS 引用,听说过它吗?

作用域:啊,是的,听说过。编译器 刚才将它声明为 foo 的一个正式参数了。给你。

引擎:一如既往的给力,作用域。再次感谢你。现在,该把 2 赋值给 a 了。

引擎:嘿,作用域,很抱歉又一次打扰你。我需要 RHS 查询 console。听说过它吗?

作用域:没关系,引擎,这是我一天到晚的工作。是的,我得到 console 了。它是一个内建对象。给你。

引擎:完美。查找 log(..)。好的,很好,它是一个函数。

引擎:嘿,作用域。你能帮我查一下 a 的 RHS 引用吗?我想我记得它,但只是想再次确认一下。

作用域:你是对的,引擎。同一个家伙,没变。给你。

引擎:酷。传递 a 的值,也就是 2,给 log(..)

小测验

检查你到目前为止的理解。确保你扮演 引擎,并与 作用域 “对话”:

1
2
3
4
5
6
function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );
  1. 找到所有的 LHS 查询(有3处!)。
  2. 找到所有的 RHS 查询(有4处!)。

答案

  1. 找出所有的 LHS 查询(有3处!)。

    c = .., a = 2(隐含的参数赋值)和 b = ..

  2. 找出所有的 RHS 查询(有4处!)。

    foo(2.., = a;, a + .. 和 .. + b

嵌套的作用域

引擎 在查找一个变量,如果在直接作用域上找不到的话,引擎 就会咨询下一个外层作用域,以此类推, 直到找到这个变量或者到达最外层作用域,即全局作用域。

例:

1
2
3
4
5
6
7
function foo(a) {
console.log( a + b );
}
var b = 2;
foo( 2 ); // 4

该例中在查找b值时,要使用RHS查询。这个过程描述为一个简单的对话:

引擎:“嘿,foo作用域,听说过 b 吗?我得到一个它的 RHS 引用。”

作用域:“没有,从没听说过。问问别人吧。”

引擎:“嘿,foo 外面的 作用域,哦,你是全局 作用域,好吧,酷。听说过 b 吗?我得到一个它的 RHS 引用。”

作用域:“是的,当然有。给你。”

错误(?)

为什么我们区别 LHS 和 RHS 那么重要?

因为在变量还没有被声明(在所有被查询的 作用域 中都没找到)的情况下,这两种类型的查询的行为不同。

考虑如下代码:

1
2
3
4
5
6
function foo(a) {
console.log( a + b );
b = a;
}
foo( 2 );

b 的 RHS 查询第一次发生时,它是找不到的。它被说成是一个“未声明”的变量,因为它在作用域中找不到。

如果 RHS 查询在嵌套的 作用域 的任何地方都找不到一个值,这会导致 引擎 抛出一个 ReferenceError。必须要注意的是这个错误的类型是 ReferenceError

相比之下,如果 引擎 在进行一个 LHS 查询,但到达了顶层(全局 作用域)都没有找到它,而且如果程序没有运行在“Strict模式”[^note-strictmode]下,那么这个全局 作用域 将会在 全局作用域中创建一个同名的新变量,并把它交还给 引擎。(能不能举一个具体的栗子?)

“不,之前没有这样的东西,但是我可以帮忙给你创建一个。”

在 ES5 中被加入的“Strict模式”[^note-strictmode],有许多与一般/宽松/懒惰模式不同的行为。其中之一就是不允许自动/隐含的全局变量创建。在这种情况下,将不会有全局 作用域 的变量交回给 LHS 查询,并且类似于 RHS 的情况, 引擎 将抛出一个 ReferenceError

现在,如果一个 RHS 查询的变量被找到了,但是你试着去做一些这个值不可能做到的事,比如将一个非函数的值作为函数运行,或者引用 null 或者 undefined 值的属性,那么 引擎 就会抛出一个不同种类的错误,称为 TypeError

ReferenceError 是关于 作用域 解析失败的,而 TypeError 暗示着 作用域 解析成功了,但是试图对这个结果进行了一个非法/不可能的动作。

复习

作用域是一组规则,它决定了一个变量(标识符)在哪里和如何被查找。这种查询也许是为了向这个变量赋值,这时变量是一个 LHS(左手边)引用,或者是为取得它的值,这时变量是一个 RHS(右手边)引用。

手动划重点:未被满足的 RHS 引用会导致 ReferenceError 被抛出。未被满足的 LHS 引用会导致一个自动的,隐含地创建的同名全局变量(如果不是“Strict模式”[^note-strictmode]),或者一个 ReferenceError(如果是“Strict模式”[^note-strictmode])。

第二章: 词法作用域

作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域

词法分析时

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar(b * 3);
}
foo( 2 ); // 2 4 12

查询

当引擎执行console.log(...)语句时,它需要查找三个变量abc

查找三个变量的方式都为一层一层从内到外的查找,一旦找到第一个匹配,作用域查询就停止了

当相同的标识符在作用域的多个层中指定时,内部的标识符会“遮蔽”外层的标识符。

全局变量自动的是全局对象(在浏览器中是window等等)的属性,所以对全局变量的引用可以不直接通过词法名称,可以通过将它作为全局对象的一个属性来间接的引用。如下:

1
window.a

欺骗词法作用域

欺骗词法作用域即修改词法作用域。欺骗词法作用域会导致性能低下。

js中欺骗词法作用域有两种机制。

eval

javaScript中的eval(...)函数接收一个字符串作为参数值,并把这个字符串的内容转换为代码。从而改变函数的作用域。

1
2
3
4
5
6
7
8
function foo(str, a) {
eval( str ); // 作弊!
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1 3

上面的代码中,变量str的值"var b = 2"的值会转换成一行代码。这个时候整个代码就可以解读为。

1
2
3
4
5
6
7
8
9
function foo(str, a) {
str = "var b = 3";
var b = 3;
console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1 3

因此控制台中输出的b值就为3而不是2

注意:当eval操作的作用域内为strict模式时,eval(...)内部做出的声明不会修改作用域。

1
2
3
4
5
6
7
function foo(str) {
"use strict";
eval( str );
console.log( a ); // ReferenceError: a is not defined
}
foo( "var a = 2" );

with

with现在已经被废弃了。

以下为with的一种使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var obj = {
a: 1,
b: 2,
c: 3
};
// 重复“obj”显得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;
// “更简单”的缩写
with (obj) {
a = 3;
b = 4;
c = 5;
}

然而以下代码才是with带来的麻烦:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作用域被泄漏了!

在上面的代码中,o1对象有a属性,而o2对象没有a属性。这就是产生问题的原因啦,foo(...)分别将o1o2作为参数。因为o1对象有a属性,所以console.log( o1.a )的输出值为2。但是o2对象没有a属性,当运行with(...)时,a = 2会按照LSH标识符查询规则,因为这不是在strict模式下,所有会在全局内声明一个变量a,并赋值为2

如果 eval(..) 函数接收一个含有一个或多个声明的代码字符串,它就会修改现存的词法作用域,而 with 语句实际上是从你传递给它的对象中凭空制造了一个 全新的词法作用域

注意: 除了使用它们是个坏主意以外,eval(..)with 都受Strict模式的影响(制约)。with 干脆就不允许使用,而虽然 eval(..) 还保有其核心功能,但各种间接形式的或不安全的 eval(..) 是不允许的。

性能

如果 eval(..)with 出现,那么它做的所有的优化几乎都会变得没有意义,所以引擎就会简单地根本不做任何优化。

你的代码几乎肯定会趋于运行的更慢,只因为你在代码的任何地方引入了一个了 eval(..)with。无论引擎将在努力限制这些悲观臆测的副作用上表现得多么聪明,都没有任何办法可以绕过这个事实:没有优化,代码就运行的更慢。

第三章:函数与块作用域

函数中的作用域

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo(a) {
var b = 2;
// 一些代码
function bar() {
// ...
}
// 更多代码
var c = 3;
}

在全局作用域中可访问foo(...),在foo(...)作用域中可访问bbar()c

隐藏于普通作用域

隐藏作用域就相当于把代码封装到一个函数中,然后在另一个函数中调用这个函数,这样函数中很多细节就隐藏在了被调用的函数中。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function doSomething(a) {
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething( 2 ); // 15
console.log( a ); // 5 自己加的测试代码

但在上述的代码中,doSomethingElse很可能被其他函数使用,改变doSomethingElse内容,违背了doSomethingElsedoSomething私有细节这一点,所以可以将代码变更为下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15

这是doSomethingElse完全为doSomething内部私有,外部无法更改。这种将私有细节保持为私有的做法是被提倡的。

避免冲突

在同一个函数中会发生使用相同的标识符,从而产生了值被覆盖的这种情况。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
function bar(a) {
i = 3; // 在外围的for循环的作用域中改变`i`
console.log( a + i );
}
for (var i=0; i<10; i++) {
bar( i * 2 ); // 噢,无限循环!
}
}
foo();

其中i = 3会该表for循环中i的值,从而使for循环进入无线循环的状态。但如果将i = 3改为var i = 3就不会产生这种情况。这是因为bar(...)函数中,未声明变量i,所以在非严格模式下,根据LSH标识符查询原则,在foo(...)i会被声明为一个全局变量。

全局“名称空间”(不是很理解)

变量冲突很有可能发生在全局作用域中。当多个库被加载到程序中时,如果没有适当的隐藏私有变量和函数,就很容易发生冲突。

所以,这样的库通常在全局作用域中使用一个独特的名称来声明一个对象,然后这些库的名称和内容组成对象中的键值对。

例如:

1
2
3
4
5
6
7
8
9
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};

(这样就能回避冲突????为啥????)

模块管理(喵?)

另一种回避冲突的选择是通过任意一种依赖管理器,使用更加现代的“模块”方式。使用这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。

应该可以看到,这些工具并不拥有可以豁免于词法作用域规则的“魔法”功能。它们简单地使用这里讲解的作用域规则,来强制标识符不会被注入任何共享的作用域,而是保持在私有的,不易冲突的作用域中,这防止了任何意外的作用域冲突。

因此,如果你选择这样做的话,你可以防御性地编码,并在实际上不使用依赖管理器的情况下,取得与使用它们相同的结果。关于模块模式的更多信息参见第五章。

(那我们就第五章再说吧)

函数作为作用域

区别下面两段代码:

第一段:

1
2
3
4
5
6
7
8
9
10
11
var a = 2;
function foo() { // <-- 插入这个
var a = 3;
console.log( a ); // 3
} // <-- 和这个
foo(); // <-- 还有这个
console.log( a ); // 2

第二段:

1
2
3
4
5
6
7
8
9
10
var a = 2;
(function foo(){ // <-- 插入这个
var a = 3;
console.log( a ); // 3
})(); // <-- 和这个
console.log( a ); // 2

两段代码都调用了foo函数,在第一段代码中foo是一个函数声明,而在第二段代码中foo是一个函数表达式。

区分函数声明和函数表达式:判断语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个东西,那么它就是一个函数声明。否则,它就是一个函数表达式。

在第一段代码中:名称foo被绑定在外围作用域中,我们可以用foo()直接调用。

在第二段代码中:名称foo没有被帮绑定在外围作用域中,而是被绑定在它的函数内部。

匿名与命名

匿名函数表达式:

1
2
3
setTimeout( function(){
console.log("I waited 1 second!");
}, 1000 );

优点:匿名函数表达式能更快的键入。

缺点:1、在栈轨迹上匿名函数没有名称,这可能会使调试更加困难。

​ 2、没有名称的情况下,如果函数想要再次引用自己,就需要使用被遗弃了的arguments.callee

​ 3、匿名函数省略的名称有利于标识函数的具体作用。

命名函数表达式:

1
2
3
setTimeout( function timeoutHandler(){ // <-- 看,我有一个名字!
console.log( "I waited 1 second!" );
}, 1000 );

内联函数表达式 很强大且很有用 —— 匿名和命名的问题并不会贬损这一点。给你的函数表达式提供一个名称就可以十分有效地解决这些缺陷,而且没有实际的坏处。最佳的方法是总是命名你的函数表达式:

栈轨迹(自己补充部分)

(函数)调用栈

函数调用栈工作方式:后进先出(LIFO:last in, first out)。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function c() {
console.log('c');
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();

在上述栗子中,a首先被运行,这是a就进入了堆栈的顶部。当a中运行b时,b被添加到堆栈的顶部。当b中运行c时,c被添加到堆栈的顶部。当c运行时,堆栈中就包含了abc。当c运行完后,就会从堆栈的顶部被移除,然后ba运行完后也同样。

为了更好的观察,可以使用console.trace()在控制台输出当前的堆栈数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function c() {
console.log('c');
console.trace();
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();

立即调用函数表达式(IIFE)(?)

传统IIFE两种书写方式:

1
2
3
4
5
6
7
8
9
10
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
1
2
3
4
5
6
7
8
9
10
var a = 2;
(function foo(){
var a = 3;
console.log( a ); // 3
}());
console.log( a ); // 2

两种书写方式在功能上完全相同,纯粹根据个人喜好选择

一种十分常见的变种:

1
2
3
4
5
6
7
8
9
10
11
var a = 2;
(function IIFE( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
})( window );
console.log( a ); // 2

传入window对象引用,将参数命名为global。这样就能通过global参数访问全局变量a

另一种变种:

1
2
3
4
5
6
7
8
9
10
11
var a = 2;
(function IIFE( def ){
def( window );
})(function def( global ){
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});

这种变种将事情的顺序倒了过来,要被执行的函数在调用和传递给它的参数 之后 给出。这种模式被用于 UMD(Universal Module Definition —— 统一模块定义)项目。一些人发现它更干净和易懂一些,虽然有点儿繁冗。

块儿作为作用域

两段常见的代码:

1
2
3
4
for (var i=0; i<10; i++) {
console.log( i ); // 0 1 2 3 4 5 6 7 8 9
}
console.log(i) // 10
1
2
3
4
5
6
7
var foo = true;
if (foo) {
var bar = foo * 2;
bar = something( bar );
console.log( bar );
}

上述两段代码中的的ibar虽然分别声明在forif块儿内,但是还是属于全局变量。

with

它是一个块儿作用域的例子,它从对象中创建的作用域仅存在于这个 with 语句的生命周期中,而不在外围作用域中。

try/catch

try/catchcatch 子句中声明的变量,是属于 catch 块儿的块儿作用域的。

栗如:

1
2
3
4
5
6
7
8
try {
undefined(); //用非法的操作强制产生一个异常!
}
catch (err) {
console.log( err ); // 好用!
}
console.log( err ); // ReferenceError: `err` not found

但是当catch里面也含有catch时,参数如果都为err许多 linter 依然会报警。所以为了避免这一问题出现,我们会用err1err2来区分他们,或者关掉linter

let

let 关键字将变量声明附着在它所在的任何块儿(通常是一个 { .. })的作用域中。换句话说,let 为它的变量声明隐含地劫持了任意块儿的作用域。

我们可以在一个语句是合法文法的任何地方,通过简单地引入一个 { .. } 来为 let 创建一个任意的可以绑定的块儿。

1
2
3
4
5
6
7
8
9
10
11
var foo = true;
if (foo) {
{ // <-- 明确的块儿
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError

使用 let 做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。即不能先使用后声明变量。

1
2
3
4
{
console.log( bar ); // ReferenceError!
let bar = 2;
}

垃圾回收

栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function process(data) {
// 做些有趣的事
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );

在上面的代码中,当运行完process( someReallyBigData )后,后面的代码就不需要someReallyBigData变量了。所以当运行click事件时,js引擎就不需要继续保持someReallyBigData变量了。为了解决这一问题可以用let

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function process(data) {
// 做些有趣的事
}
// 运行过后,任何定义在这个块中的东西都可以消失了
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );

let循环(*)

1
2
3
4
5
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError

let循环的特点是:每一次循环都会重新绑定i,然后再对它进行赋值。上述代码等价于:

1
2
3
4
5
6
7
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每次迭代都重新绑定
console.log( i );
}
}

const

const声明的变量也为块作用域,只不过他声明后的值是不可改变的。

1
2
3
4
5
6
7
8
9
10
11
12
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 存在于包含它的`if`作用域中
a = 3; // 没问题!
b = 4; // 错误!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!

第四章:提升

先考虑以下代码:

1
2
3
4
5
a = 2;
var a;
console.log( a ); // 2
1
2
3
console.log( a ); // undefined
var a = 2;

编译器再次来袭(?)

引擎将会在解释执行代码之前先对它进行编译。编译的部分过程就是找到所有声明,并将它们关联在合适的作用域上

所以在代码执行之前,所有函数和变量的声明都会被首先处理。

对于var a = 2;这段代码,js实际认为它是两个语句:var a;a = 2;。第一个语句:声明,是在编译阶段处理的,是编译期的任务。第二个语句:赋值,为了执行阶段留在原处,是执行期的任务。

所以上面的第一段代码被解释为:

1
var a;
1
2
a = 2;
console.log( a ); // 2

第二段代码被解释为

1
var a;
1
2
console.log( a );
a = 2;

变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。

1
2
3
4
5
6
7
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}

上述的代码可以解释为:

1
2
3
4
5
6
7
8
9
function foo () {
var a;
console.log( a );
a = 2;
}
foo();

函数声明会被提升,但函数表达式不会。

1
2
3
4
5
6
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};

这段代码可以解释为:

1
2
3
4
5
6
7
8
var foo;
foo(); //TypeError 虽然声明了,但是foo没有赋值,运行一个undefined的值是一种错误的操作。
bar(); //ReferenceError 没有声明
foo = function () {
var bar = ...self...; (喵喵喵???)
}

函数优先

函数会比变量先提升。

1
2
3
4
5
6
7
8
9
10
11
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};

这段代码被引擎解释为:

1
2
3
4
5
6
7
8
9
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};

var foo;被省略了,因为在函数声明之后这一步是重复操作了。即便它出现在 function foo()... 声明之前,因为函数声明是在普通变量之前被提升的。

对于多个声明,后续声明会覆盖前一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
foo(); // 3
function foo() {
console.log( 1 );
}
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 3 );
}

在普通的块儿内部出现的函数声明一般会被提升至外围的作用域,而不是像这段代码暗示的那样有条件地被定义:

basic
1
2
3
4
5
6
7
8
9
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log( "a" ); }
}
else {
function foo() { console.log( "b" ); }
}

上面这种行为是不可靠的,而且是未来版本的 JavaScript 将要改变的对象,所以避免在块儿中声明函数可能是最好的做法。

第五章:作用域闭包

事实真相

闭包的定义:

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。

一个闭包的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 -- 哇噢,看到闭包了,伙计。

foo中,声明了一个bar函数,它可以访问外围作用域的a变量。通过return bar;可以成功的将bar这个函数传给其他任意变量。将foo()赋值给baz变量,这时直接调用baz()就能成功的在控制台输出变量a

在上述的代码中,foo()按理执行完之后就会被引擎启用垃圾回收器 来回收foo()内部的内容。但是闭包不会让这一事件发生,内部的作用域依然可以使用。

bar() 依然拥有对那个作用域的引用,而这个引用称为闭包。

而且,函数也可以作为参数传递,在其他地方使用这个传递进去的参数,也是闭包的一种实现形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 看妈妈,我看到闭包了!
}

也可以将函数传给外部作用域的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将`baz`赋值给一个全局变量
}
function bar() {
fn(); // 看妈妈,我看到闭包了!
}
foo();
bar(); // 2

现在我能看到了

举一个特殊栗子:

1
2
3
4
5
6
7
8
9
function wait(message) {
setTimeout( function timer(){
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

setTimeout内的timer拥有覆盖wait作用域的闭包,保持着对message变量的引用。

正是因为闭包的存在,才能在函数执行了1000ms后,在wait()内部作用域已经消失的情况下,输出变量message的值。

循环+闭包(*)

用来展示闭包最常见最权威的例子是老实巴交的 for 循环。

1
2
3
4
5
for (var i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}

当执行上述代码,会发现每隔一秒会输出一个6。

setTimeout下的循环和闭包(自己补充部分)

对于上述代码的理解:结合异步闭包作用域的知识。

对setTimeout的理解

首先要理清对setTimeout的理解:

setTimeout的延迟不是绝对精确的;
setTimeout的意思是传递一个函数,延迟一段时候把该函数添加到队列当中,并不是立即执行;
所以说如果当前正在运行的代码没有运行完,即使延迟的时间已经过完,该函数会等待到函数队列中前面所有的函数运行完毕之后才会运行;

也就是说所有传递给setTimeout的回调方法都会在整个环境下的所有代码运行完毕之后执行。

比如:

1
2
3
4
5
6
7
8
9
setTimeout(function(){
console.log("here");
}, 2000);
var i = 0;
//具体数值根据你的计算机CPU来决定,达到延迟效果就好
while (i < 300000000) {
i ++;
}
console.log("test");

上面的代码运行时,会先输出test,2秒后再输出here。

为什么是一秒

因为根据上述的理解,setTimeout会在for循环结束后运行,所以队列中会依次加入延迟1秒、2秒、3秒…的setTimeout函数,所以在运行的时候延迟1秒的会先运行,然后对于延迟2秒的在延迟1秒的setTimeout运行的同时也跟着延迟了1秒,所以当延迟1秒的setTimeout运行结束后,只需要延迟1秒就可以开始运行函数。 延迟3秒、4秒…的同理。

为什么每次都显示6

可以将循环转化为:

1
2
var i;
for(i = 1; i <= 5; i++) {...}

首先说明对于闭包的理解:对函数类型的值进行传递时,保留对它被声明的位置所处的作用域的引用。

timer函数是在setTimeout中声明的,当运行console.log( i )时,这个itimer里面没有声明,所以向外层作用域找,这时候可以找到全局作用域上的i。当timer执行时,循环已经结束了,所以i的值为6。

改进后输出1,2,3,4,5

第一种方式:

1
2
3
4
5
6
7
8
for (var i=1; i<=5; i++) {
(function(){
var j = i;
setTimeout( function timer(){
console.log( j );
}, j*1000 );
})();
}

函数在每次迭代时,持有一个i值的拷贝。

第二种方式:

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function (j) {
setTimeout(function timer () {
console.log( j );
}, j * 1000);
})(i);
}

这种方式知识上面形式的一种改写,他们都会利用IIFE来解决这一问题,都是用立即执行函数表达式创造了新的函数作用域将timer函数包裹了起来,并用j捕获了每次循环时的i。只不过第二种方式将j作为形参,i作为实参。

第三种方式:

1
2
3
4
5
for (let i=1; i<=5; i++) {
setTimeout( function timer(){
console.log( i );
}, i*1000 );
}

let声明的变量每次都会创建一个块作用域,将上面的代码经过babel转码为ES5我们可以看到:

1
2
3
4
5
6
7
8
9
var _loop = function _loop(i) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
};
for (var i = 1; i <= 5; i++) {
_loop(i);
}

它为每一次循环都创建了一个块作用域。

第四种方式:

1
2
3
4
5
6
for (var i=1; i<=5; i++) {
let j = i;
setTimeout( function timer(){
console.log( j );
}, j*1000 );
}

这种方式原理和第三种方式相同。

模块

闭包的另一种运用是在模块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

这段代码就是闭包在模块中的一个运用。实现模块的一个最常用方法经常被称为“揭示模块”。

对于上述代码需要注意的是:

第一:coolModule()只是一个函数,它只有被调用之后才能称为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。

第二:CoolModule() 函数返回一个对象,通过对象字面量语法 { key: value, ... } 标记。可以很恰当地认为这个返回值对象实质上是一个 我们模块的公有API

行使模块模式有两个“必要条件”:

  1. 必须有一个外部的外围函数,而且它必须至少被调用一次(每次创建一个新的模块实例)。
  2. 外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问和/或修改这个私有状态。

一个仅带有一个函数属性的对象不是 真正 的模块。从可观察的角度来说,一个从函数调用中返回的对象,仅带有数据属性而没有闭包的函数,也不是 真正 的模块。

只需要一个实例的模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

使用IIFE实现。

同时,模块是函数,所以它们可以接受参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function CoolModule(id) {
function identify() {
console.log( id );
}
return {
identify: identify
};
}
var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

同时,我们还可以通过模块内部函数修改公有API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var foo = (function CoolModule(id) {
function change() {
// 修改公有 API
publicAPI.identify = identify2;
}
function identify1() {
console.log( id );
}
function identify2() {
console.log( id.toUpperCase() );
}
var publicAPI = {
change: change,
identify: identify1
};
return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

现代的模块

未来的模块

附录A:动态作用域

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log( a ); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();

foo() 的词法作用域中指向 a 的 RHS 引用将被解析为全局变量 a,它将导致输出结果为值 2

相比之下,动态作用域本身不关心函数和作用域是在哪里和如何被声明的,而是关心 它们是从何处被调用的。换句话说,它的作用域链条是基于调用栈的,而不是代码中作用域的嵌套。

附录B:填补块作用域